Skip to content

Keep Neo4j credentials out of query failure messages - #1314

Open
malberts wants to merge 1 commit into
masterfrom
fix/cypher-query-credential-leak
Open

Keep Neo4j credentials out of query failure messages#1314
malberts wants to merge 1 commit into
masterfrom
fix/cypher-query-credential-leak

Conversation

@malberts

@malberts malberts commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

The Neo4j driver reports an unreachable server by quoting the Bolt URI it dialed, credentials
included. Neo4jQueryService rethrew that message verbatim as BackendUnavailableException, and
the query surfaces pass it straight to the caller: CypherQueryApi puts it in the 503 body, on a
route neowiki-query leaves open to anonymous users by default.

Against a wiki pointed at an unreachable Neo4j, an unauthenticated request returns the password:

$ curl -X POST http://wiki.example/w/rest.php/neowiki/v0/query/cypher \
    -H 'Content-Type: application/json' \
    -d '{"cypher":"MATCH (p:Page) RETURN p.name","parameters":{}}'
HTTP 503
{"errorType":"backendUnavailable","message":"Cannot connect to any server on alias: default with Uris: ('bolt://neowiki_read:S3cr3t@graph.example:7687')"}

Both backend-failure paths now go through BackendFailureMessage::withoutCredentials, which the
ten other places that surface a backend message already use. The same request then answers:

{"errorType":"backendUnavailable","message":"Cannot connect to any server on alias: default with Uris: ('bolt://graph.example:7687')"}

The message still names the server it could not reach, so it stays as useful for diagnosis. Fixing
it in the service rather than in the REST handler covers the other two query surfaces,
{{#cypher_raw}} and nw.query(), which take the same message from the same place.

#1258 extends the same helper to three further surfaces and is open in parallel. It leaves the
query path alone and this leaves its three alone, so the two do not overlap and can land in either
order.

Why now

The exposure is old, but it used to be bounded: the backend was a health-gated container on the
compose network, holding a password the stack generated for itself. #1308 made the Neo4j address
configurable, so a remote or managed server is now a supported target, and a remote server is one
that has maintenance windows and expiring certificates. An unreachable backend went from a local
misconfiguration to a routine operating condition.

Worth noting for severity: separate read and write Neo4j users need Enterprise Edition, and
docs/operations/installation.md tells Community Edition installs to point both URLs at the same
user. On those installs the read URL that queries use carries the wiki's only Neo4j credentials.

Considered, omitted

  • Scrubbing in CypherQueryApi instead. It would leave {{#cypher_raw}} and nw.query() leaking.
  • Widening this to translateNeo4jException. Those messages come from getNeo4jMessage(), which
    reports what the query did wrong rather than what was dialed, so they carry no URI.
  • Auditing the SPARQL plugin for the same shape. Out of scope here; worth its own pass.

Testing

The two new tests fail on master with the password in the assertion output, and pass with the fix.
Beyond that the behaviour was reproduced end to end on a live stack, pointing the wiki at an
unreachable host with a canary password: anonymous POST returns the canary before the change, and
the host without it after. Full PHPUnit, PHPCS and PHPStan are green, with the suite matching
master's baseline.

The existing CypherQueryApiTest stubs a benign message, which is why CI never caught this; the new
tests use a message shaped like the driver's real one.

AI-authored — Claude Code, Opus 5 (1M context); surfaced by an AI review of #1308, then challenged by @malberts and confirmed by reproduction rather than by reading; diff not yet human-reviewed; tests written first and seen failing on master, leak and fix both reproduced end to end on a live stack with a canary password, full PHPUnit, PHPCS and PHPStan green against master's baseline, CI green.

The driver reports an unreachable server by quoting the Bolt URI it dialed,
credentials included. Neo4jQueryService rethrew that message verbatim as
BackendUnavailableException, and the query surfaces hand it straight to the
caller: CypherQueryApi returns it in the 503 body, on a route the
neowiki-query right leaves open to anonymous users by default. Reproduced
against an unreachable server, an unauthenticated POST to
/rest.php/neowiki/v0/query/cypher returned the configured Neo4j password.

Route both backend-failure paths through BackendFailureMessage, as the ten
other places that surface a backend message already do. The message keeps
naming the server it could not reach, so it stays as useful as before.

Community Edition installs cannot separate the read user from the write user,
so the read URL that queries use commonly carries the wiki's only Neo4j
credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@malberts
malberts marked this pull request as ready for review August 23, 2026 08:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant